defmodule Pango.SagaCompensate do
    @moduledoc """
    While Sage will do its best to compensate failures in a transaction and leave a system in a consistent state,
        there are some edge cases where it wouldn't be possible.
    1. What if my transaction has bugs or other errors?
        Transactions are wrapped in a try..catch block and would tolerate any exception, exit or rescue.
        And after executing compensations, an error will be reraised.
    2. What if my compensation has bugs or other errors?
        By default, compensations would not try to handle any kinds of errors. But you can write an adapter to handle those.
        For more information see Critical Error Handling section.
    3. What if the process that executes Sage or whole node fails?
        Right now Sage doesn't provide a way to tolerate failures of executing processes.
        (However, there is an RFC that aims for that.)
    4. What if an external API call fails and it's impossible to revert a step?
        In such cases, the process which is handling the pipeline will crash and the exception will be thrown.
        Make sure that you have a way of reacting to such cases (in some cases it might be acceptable to ignore the error while others might require a manual intervention).
    5. Can I be absolutely sure that everything went well?
        Unfortunately, no. As with any other distributed system, messages could be lost, the network could go down,
        hardware could fail etc. There is no way to programmatically solve all those cases,
        even retrying compensations won't help in some of such cases.
        For example, it's possible that a reply from an external API is lost even though a request actually succeeded.
        In such cases, you might want to retry the compensation which might have an unexpected result.
        Best way to solve that issue is to write compensations in an idempotent way and to always make sure that you have proper monitoring tools in place.

    saga 补偿失败处理
    put: 写数据库的时候,必须把条件写好,只有满足一定条件才会写数据库,避免多线程,重复执行等问题
       比如 update xxx set status = $a where id = $id AND status = $status
    如果不能补偿的, 就不要 put 到 cache 里
    """


    @doc """
        启动Application的时候加载文件里的数据
    """
    @callback load() :: :ok
    @doc """
        添加 MFA, 会判断 cache 里是否存在该 mfa
    """
    @callback put(atom, atom, list) :: :ok
    @doc """
        删除 MFA
    """
    @callback del(atom, atom, list) :: :ok

    @doc """
        遍历 cache, 执行 MFA
    """
    @callback run() :: :ok
    @doc """
        缓存持久化到文件
    """
    @callback dump() :: :ok
    @callback list() :: list
    @callback cache_spec() :: keyword

    @doc """
    opts 要设置 table 名称, dump_file 路径
    use example:
       use Pango.SagaCompensate, table: $table, dump_file: $dump_file
    """
    defmacro __using__(opts) do
        quote location: :keep,
              bind_quoted: [
                  opts: opts
              ] do
            @behaviour Pango.SagaCompensate
            @table opts[:table]
            @dump_file opts[:dump_file]
            File.dir?(@dump_file) && throw({:error, "dump_file 必须是文件"})

            import Cachex.Spec
            require Logger
            @cache_reclaim 0.5

            def load() do
                File.exists?(@dump_file) && Cachex.load(@table, @dump_file)
                :timer.apply_after(5_000, __MODULE__, :run, [])
                :ok
            end

            def put(m, f, a) do
                Cachex.get_and_update(
                    @table,
                    {m, f},
                    fn
                        (nil) ->
                            :timer.apply_after(1_000, __MODULE__, :run, [])
                            {:commit, [a]}
                        (cur) ->
                            if !Enum.member?(cur, a) do
                                {:commit, [a | cur]}
                            else
                                {:ignore, :ok}
                            end
                    end
                )
                :ok
            end

            def del(m, f, a) do
                key = {m, f}
                Cachex.get_and_update(
                    @table,
                    key,
                    fn
                        (nil) ->
                            {:ignore, :ok}
                        (cur) ->
                            case List.delete(cur, a) do
                                [] ->
                                    Cachex.del(@table, key)
                                rest ->
                                    {:commit, rest}
                            end
                    end
                )
                :ok
            end


            def run() do
                Enum.each(
                    elem(Cachex.keys(@table), 1),
                    fn ({m, f} = key) ->
                        Cachex.transaction(
                            @table,
                            [key],
                            fn table ->
                                case Cachex.get(table, key) do
                                    {:ok, nil} ->
                                        :ok
                                    {:ok, list} ->
                                        rest = Enum.reduce(
                                            list,
                                            [],
                                            fn h, acc ->
                                                Logger.debug("Start Compensate, MFA:#{inspect({m, f, h})}")
                                                try do
                                                    case apply(m, f, h) do
                                                        :ok ->
                                                            acc
                                                        reason ->
                                                            log = "Apply Error, MFA: #{inspect({m, f, h})}, "
                                                            <> "reason: #{inspect(reason)}"
                                                            Logger.error(log)
                                                            [h | acc]
                                                    end
                                                rescue
                                                    err ->
                                                        log = "Apply Error, MFA: #{inspect({m, f, h})}, "
                                                        <> "reason: #{inspect(err)}, stack: #{inspect(__STACKTRACE__)}"
                                                        Logger.error(log)
                                                        [h | acc]
                                                catch
                                                    {:error, err} ->
                                                        log = "Apply Throw Error, MFA: #{inspect({m, f, h})}, "
                                                        <> "reason: #{inspect(err)}"
                                                        Logger.error(log)
                                                        acc
                                                end
                                            end
                                        )
                                        if rest != [] do
                                            Cachex.put(table, key, rest)
                                        else
                                            Cachex.del(table, key)
                                        end
                                end
                            end
                        )
                    end
                )
                Cachex.keys(@table) != {:ok, []} &&
                    :timer.apply_after(1_000, __MODULE__, :run, [])
                dump()
                :ok
            end


            def dump() do
                Logger.debug("Dump SagaCompensate, ts:#{TimeLib.now_string()}")
                dir = Path.dirname(@dump_file)
                File.dir?(dir) || File.mkdir!(dir)
                Cachex.dump(@table, @dump_file)
                :ok
            end

            def list() do
                :ets.tab2list(@table)
            end

            def cache_spec() do
                Supervisor.Spec.worker(
                    Cachex,
                    [
                        @table,
                        [
                            limit: Cachex.Spec.limit(
                                policy: Cachex.Policy.LRW,
                                reclaim: @cache_reclaim
                            )
                        ]
                    ],
                    id: @table
                )
            end

            defoverridable put: 3, del: 3, load: 0, run: 0, dump: 0, list: 0, cache_spec: 0

        end

    end

end
